Skip to content

MAIN - #17280

Open
niteeshkanna-sh wants to merge 264 commits into
react:mainfrom
niteeshkanna-sh:main
Open

MAIN#17280
niteeshkanna-sh wants to merge 264 commits into
react:mainfrom
niteeshkanna-sh:main

Conversation

@niteeshkanna-sh

Copy link
Copy Markdown

No description provided.

@meta-cla

meta-cla Bot commented Aug 27, 2026

Copy link
Copy Markdown

Hi @niteeshkanna-sh!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

niteeshkanna-sh and others added 29 commits September 12, 2026 16:02
config.php was committed with a real database name, user and password. This
repository is public, so those values were readable by anyone who found it.

Untracks the file and adds it to .gitignore. The working copy and the server's
copy are both untouched, so the admin panel keeps running.

This does not remove the file from git history; rotating the database password
in the hosting panel is what actually closes the exposure.
Replaces the placeholders the site shipped with -- +91 90000 00000 and
hello@niteshacars.in -- which were visible to customers on the live domain.

Four places: the header's Call us button, and the footer's phone and email,
each as both the visible text and the tel:/mailto: href.

The tel: href carries no spaces (+916374942976) so phone dialers parse it,
while the visible text keeps the readable +91 63749 42976 grouping.

Verified in a browser: all four resolve to the new values, and a grep over
my-app confirms no placeholder survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the placeholders the site shipped with -- +91 90000 00000 and
hello@niteshacars.in -- which were visible to customers on the live domain.

Four places: the header's Call us button, and the footer's phone and email,
each as both the visible text and the tel:/mailto: href.

The tel: href carries no spaces so phone dialers parse it, while the visible
text keeps the readable +91 63749 42976 grouping.

Verified in a browser, and by grepping my-app for surviving placeholders.
The site shipped with six invented vehicles -- a Brezza, Swift, City, Innova,
Baleno and Nexon EV -- with invented rates, KM limits and deposits. On a live
domain that is worse than showing nothing: a customer could read a price for a
car that does not exist and call about it.

cars.ts is now an empty array with a commented template for one entry. Adding
the real fleet is filling it back in; nothing else has to change.

The three places that read the array each handle it being empty, so the page
stays coherent rather than rendering a blank grid:

  Fleet     shows a card inviting an enquiry or a call, and hides the
            body-type filters, which would otherwise be a row of controls
            that filter nothing
  Enquiry   hides the car picker, and relabels the free-text field to
            "What kind of car do you need?" so the visitor can say what the
            dropdown would have asked
  Header    unchanged; it never read the fleet

Verified in a browser: no placeholder name appears anywhere on the page, zero
cards and zero filters render, the empty-state card shows, the car picker is
gone, the enquiry form still works, and there are no page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Replaces the single scrolling page with six routes matching the old site's
menu: Home, About Us, Our Cars, Tariff, Blog, Contact, plus a 404.

  /          Home      hero, how it works, a closing call to action
  /about     About Us  what hiring involves; deliberately generic copy
  /cars      Our Cars  the fleet listing
  /tariff    Tariff    a rate table built from the same fleet data
  /blog      Blog      post list, currently empty
  /contact   Contact   phone and email cards, and the enquiry form
  *          404       a real page rather than a blank screen

Uses react-router-dom. The nav marks the current page with a gold dot above
it, matching the menu on the old site, and collapses to a button-toggled list
below the lg breakpoint so the six items do not crowd a phone.

Two components were written for one page and had to be rewired. Fleet took an
onEnquire callback to scroll down to the form; it now links to
/contact?car=<name>, and Enquiry reads that query parameter, so choosing a car
survives the navigation between pages. Neither takes props any more.

Tariff and Blog handle being empty the same way the fleet listing does: a card
that invites a call or an enquiry, rather than an empty table or an empty list.
About and Blog carry TODO comments -- their copy states only what the booking
flow actually does, because inventing a founding year, a fleet size or posts
would put untrue claims on a live site.

scripts/spa-fallback.mjs copies dist/index.html to dist/404.html after every
build. GitHub Pages serves static files and knows nothing about client-side
routes, so without it a direct visit to /about, or a refresh on /cars, returns
Pages' own 404 and the app never boots. Pages does serve 404.html for anything
it cannot match, so an exact copy loads the app and the router resolves the
URL. Copied rather than symlinked because the Pages artifact upload does not
follow symlinks, and written in Node so the build still works on Windows.

ScrollToTop resets scroll on navigation, since a client-side route change
otherwise keeps the previous scroll position and opens a page halfway down.
Hash links are left alone so #anchors still work.

Verified in a browser: all seven routes render the right heading, clicking a
nav item changes the URL, /contact?car= is handled with the fleet empty, the
enquiry form is present, the mobile menu opens with all seven links, and no
page errors are raised. 404.html is byte-identical to index.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
)

Two changes shipped together.

The six invented vehicles are gone. cars.ts is now an empty array with a
commented template; everything reading it handles being empty, so no page
renders a hole. Adding the real fleet is filling the array back in.

The single scrolling page becomes six routes matching the old site's menu --
Home, About Us, Our Cars, Tariff, Blog, Contact -- plus a 404 page. The nav
marks the current page with a gold dot and collapses to a toggle on phones.

Fleet and Enquiry were written for one page; a car chosen on the fleet page
now travels as /contact?car=<name> so the choice survives the navigation.

scripts/spa-fallback.mjs copies dist/index.html to dist/404.html after every
build. GitHub Pages knows nothing about client-side routes, so without it a
direct visit to /about or a refresh on /cars returns Pages' own 404 and the
app never boots.

About and Blog carry TODO comments: their copy states only what the booking
flow actually does, rather than inventing a founding year or posts.
Two pieces: a public read-only endpoint so the panel becomes the single place
the fleet is managed, and the SEO work needed for a client-rendered site on a
static host.

## The endpoint

api/public-vehicles.php returns vehicles whose status is Available, with rates
read from the dated rate card the same way the admin reads them, so a price
scheduled for next month does not leak out early. Adding a car in the panel
puts it on the site; setting one to Maintenance takes it off.

It deliberately omits columns the admin's own endpoint returns: reg_number, a
plate is not the public's business; current_km, which reveals how hard a car
has been worked; created_by and created_at, internal; colour, a swatch for the
panel's UI. A vehicle with no rate card is skipped rather than listed with a
blank price, since that would invite an enquiry nobody can answer.

CORS uses the existing public_site_origin allowlist, echoed rather than
wildcarded, matching enquiry-submit.php. GET only. No session, no writes.

The site falls back to src/data/cars.ts when the request fails -- offline, a
CORS rejection, or the endpoint not yet uploaded. That file is empty, so the
page degrades to its "ask us what's available" state instead of an error.

## SEO

The app renders in the browser, so every route was served the same index.html
carrying the home page's title. Google runs JavaScript and would eventually see
the right tags, but WhatsApp, Facebook and X do not run it at all -- they read
the HTML as served and stop. Sharing niteshacars.in/tariff previewed as the
home page.

scripts/prerender-seo.mjs now writes a directory per route with that route's
title, description, canonical and og: tags baked in. Pages serves /tariff/ from
/tariff/index.html. dist/404.html keeps the root page's tags, since it is what
Pages serves for unmatched paths.

src/data/seo.json is the single source for those strings and is also read by
src/lib/useSeo.ts, which updates the same tags on client-side navigation -- the
app never re-requests HTML after boot, so without it the title would stay on
whichever page loaded first.

Also added: sitemap.xml generated from the same file, robots.txt pointing at
it, and AutoRental structured data in index.html carrying the business name,
phone and email, which is what local search results are built from.

This replaces scripts/spa-fallback.mjs; the 404 copy now happens here.

Verified: php -l passes on the endpoint; all six routes build with their own
title, canonical and og:url; 404.html carries the root tags; the sitemap lists
every route. In a browser with the API unreachable, the listing falls back to
the empty state rather than hanging, the title changes on navigation, and no
page errors are raised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
api/public-vehicles.php returns Available vehicles with rates from the dated
rate card, so adding a car in the admin panel puts it on the site. It omits
registration numbers, current KM and internal columns, and skips vehicles with
no rate card rather than listing a blank price. CORS reuses the existing
public_site_origin allowlist. The site falls back to an empty cars.ts when the
request fails, so it degrades rather than erroring.

For SEO, every route was served the same index.html carrying the home page's
title. Google runs JavaScript, but WhatsApp, Facebook and X do not -- sharing
/tariff previewed as the home page. The build now writes a directory per route
with its own title, description, canonical and og: tags, plus sitemap.xml,
robots.txt and AutoRental structured data.

The endpoint still has to be uploaded to the server; the repo is not deployed
to Hostinger.
The metadata described the service generically, so it competed with every
self-drive rental in India and matched none of the searches that actually
convert. Someone looking for a car does not search "self-drive car hire" --
they search "car rental Nagercoil".

Titles and descriptions now name the district and its towns, kept inside the
lengths search results truncate at: every title is 42-53 characters against a
~60 limit, and every description 107-150 against ~155, so none is cut mid
sentence.

The structured data declares the service area rather than only the business:
areaServed is Kanyakumari district within Tamil Nadu, and the address carries
Nagercoil and Tamil Nadu. No street address or coordinates are invented --
those have to be real to be worth anything, and a wrong one is worse than none.

Local ranking also reads the visible page, not just the head. AreasServed
lists the twelve main towns as text on the home and contact pages, so a search
for "car rental Marthandam" lands on a page that says Marthandam. The hero now
names the district and four largest towns in its opening paragraph.

Verified in a browser: all six towns checked appear on the home page, the
title is the district one, and the JSON-LD parses with areaServed "Kanyakumari
district", locality Nagercoil, region Tamil Nadu. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Search Console shows the site at position 3.2 for its main query, with clicks
down 47% over 28 days. The indexed listing explains part of why: it advertises
"NiteSha Cars & Bikes ... premium cars, wedding rentals, and tourist vehicle
services", and the rebuilt site mentioned none of that. Replacing a page that
ranks with one covering less of what the business does is how a ranking is
lost, and the click drop may already be that.

Three services now have their own pages, because they are separate searches
and a page can only rank for what it is about. Somebody hiring a scooter is
not the person booking a wedding car.

  /bikes             two-wheelers, hourly to weekly
  /wedding-cars      decorated cars, reserved dates, vehicles for the family
  /tourist-vehicles  cars and vans with a driver, sightseeing and temple tours

The brand was wrong throughout: the site said "Nitesha Cars", the indexed
listing and the business are "NiteSha Cars & Bikes". Inconsistent naming costs
local ranking directly, so it now comes from seo.json wherever it appears.

Titles now lead with Nagercoil rather than Kanyakumari. The district name is
what people add after the town, not before it, and the competitor ranking
above us leads with Nagercoil too. All nine titles are 40-51 characters and
all nine descriptions 107-152, inside where results truncate.

The home h1 was "Take the wheel. We'll handle the rest." -- the strongest
on-page heading, carrying no keyword at all. It now reads "Self drive car &
bike rental in Nagercoil and across Kanyakumari district", with the slogan
kept as a tagline beneath it.

Nine nav items do not fit a desktop row, so the four services sit behind one
Services trigger, which keeps the six-item menu shape the old site had. Each
service keeps its own route. The mobile menu lists all nine flat.

Menus now close from the click that navigates rather than an effect watching
the path, which oxlint flagged as cascading an extra render.

Verified in a browser: all nine routes render their own h1, the brand appears
in the header, the dropdown holds four items and closes after navigating, and
the home page links to each service two or three times over -- nav, card grid
and footer. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Motion throughout: the hero staggers in, sections and cards fade up as they
are scrolled to, each route change fades the new page in, menus drop open, and
cards lift on hover.

Built on CSS keyframes and one IntersectionObserver rather than a motion
library. Framer Motion is around 34 kB gzipped; this is 0.43 kB, measured
against the previous build. That matters here specifically: the site competes
on local search, Core Web Vitals feed that, and most visitors arrive on a
mid-range phone over mobile data.

Only opacity and transform are animated. Both are composited, so no frame
triggers layout or paint -- animating height, top or margin is what makes a
site judder on the hardware most people actually have. translate3d and
scale3d keep the work on the GPU.

The reveal uses an IntersectionObserver, not a scroll listener: intersections
are reported off the main thread, where a scroll handler would run on every
frame of every scroll. Each element unobserves after firing, so content
animates once rather than re-animating whenever it passes the viewport again.

Route transitions need main to be keyed on the pathname. Without the key React
reuses the DOM node, no mount happens, and the animation never replays.

prefers-reduced-motion is handled in both directions. The whole motion block
is inside a no-preference query, and a reduce query resets [data-reveal] to
full opacity -- without that second rule the reveal would leave every section
permanently invisible for anyone who has asked their system for less motion,
which is worse than having no animation at all.

AreasServed is rewritten rather than patched: wrapping its list items left the
JSX unbalanced, and the structure reads better with the chip as a span inside
the revealed li.

Verified in a browser: a below-fold section measures opacity 0 before scroll
and 1 after, the hero is already at full opacity on load, an animationstart
event for fade-up fires on main at every route change, and under reduced
motion zero elements are left invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Kanyakumari district sends a lot of people to the Gulf, Singapore and
Malaysia, and they come back for weddings, the December holidays and family
occasions. Their problems are not a local customer's: they book months ahead
from another country, land at an airport in a different state, and usually
need a vehicle for weeks rather than days. None of that was addressed
anywhere on the site.

/nri covers the things they actually ask about -- arranging a vehicle before
flying, being met at Trivandrum, which licence works, and dropping the car
back on the way out. Trivandrum is named specifically because it is the
nearest international airport to Nagercoil and where most Gulf and Singapore
flights land, and because "car rental Trivandrum airport Nagercoil" is a
search somebody makes.

WhatsApp is the primary call to action on this page, per the owner. It costs
nothing from abroad and survives the time difference, where a phone call to
India from the Gulf does neither. The link is wa.me with the country code and
no punctuation, which is what the format requires -- a space or a leading plus
fails silently. It carries a prefilled message with blanks for arrival and
return dates, so the first message already has the information we need.

The two offers the owner chose, a long-stay discount and airport pickup, are
described without figures. No percentage, no rate, no "free". They have not
set the terms, and a number on a live page is a promise a customer can hold
them to; inventing one would commit their money. The copy says the daily rate
comes down for longer hires and to ask for the rate, which is true and still
converts. A test asserts no percentage or rupee figure appears on the page.

The licence guidance is the part most worth getting right, since being turned
away at the counter after a night flight is the failure people fear: an Indian
licence works if unexpired, a foreign one needs an International Driving
Permit alongside, passport and visa or OCI as photo ID, and send a photo ahead
so it is confirmed before travelling rather than on arrival.

The services grid now holds five cards and moves to a 3-then-5 column layout
so none is orphaned on its own row.

Verified in a browser: the page renders with its own title, the WhatsApp link
resolves to wa.me/916374942976 with the prefilled text and opens in a new tab
with noopener, Trivandrum, the IDP rule, long-stay and weddings are all
mentioned, no invented figure appears anywhere, and the home page links to it
twice. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"Monthly car rental Nagercoil" is a different search from "self drive car
rental Nagercoil", and the competitor ranking above us has a page for it while
we had nothing. A page can only rank for what it is about.

Their version is a section: a paragraph, four bullets on what affects the
rate, and a button. This goes further, because the way to outrank a thin page
is to answer what it leaves out. Six questions somebody actually has before
handing over a month of hire -- how the KM allowance works across a month
rather than a day, who services the car while they have it, what happens on a
breakdown, whether they can extend, what deposit is held, what to bring -- and
four audiences, since a family back from the Gulf and someone on a work
posting arrive with different worries.

Their page does confirm one thing: it quotes no price either, only what
affects the rate. So the no-figures approach is not a handicap against the
site currently ranking first. This page lists five factors and offers a
same-day quote.

Both spellings of the vehicle class are present. The page said "seven-seater",
but the search is typed "7 seater" -- the numeral now appears here and on the
fleet page, where somebody looking for one would land.

Monthly is linked from the NRI page's long-stay step, which is the natural
path: a visitor reading about staying for weeks is the person who wants this.

Verified in a browser: the page renders with its own h1 and title, mentions
7 seater, NRI, work postings, extra-KM, deposits and servicing, carries six
Q&A entries, and contains no invented figure. All eleven routes still render
an h1. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Monthly hire, NRI visitors and weddings were reachable only through the nav or
a small card in the services grid, where they read as four equal options. They
are not equal: a month-long hire, a family flying in for a season and a wedding
booking are each worth many times a weekend rental, and they are the bookings
worth putting in front of someone before they leave the page.

Each is now a band on navy rather than the page's sand, so it reads as an
offer block instead of more body copy. The layout follows what works on the
competitor's monthly section -- icon, heading, a gold line naming the
question in the visitor's head, a paragraph, and a panel of four checked
points ending in a call to action -- because it is a good pattern, not because
it is theirs. Ours carries three of these where they have one.

Still no figures anywhere in the band. The points say what decides the rate,
not what the rate is; a test asserts no percentage or rupee figure appears.

Verified in a browser: three blocks render, each call to action points at its
own route and navigates, the band contains no invented price, and at 390px
the blocks stack with no horizontal overflow. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The layout pattern only: two overlapping photographs with a circular badge
across them, and beside it a pill label, a heading, a paragraph and numbered
accordion rows. The content is ours. Nobody's founder story is borrowed, and
nothing is claimed about the fleet that has not been established.

The three rows say what is actually true and worth knowing before hiring:
the rate, KM allowance, extra-KM rate and deposit are all stated before the
vehicle is handed over; we cover the whole district including airport
delivery; and cars, bikes, wedding vehicles and drivers come from one place
rather than four.

The circular badge is an SVG textPath around a circle, not letters rotated
individually with transforms -- that approach drifts at different font sizes
and falls apart when the font falls back. Only the svg spins, so the arrow in
the middle stays upright, and it stops under prefers-reduced-motion.

The accordion is buttons with aria-expanded rather than details and summary,
because only one row should be open at a time and native details has no notion
of a group. Clicking an open row closes it, so the section can be collapsed
entirely.

Reuses the two web-sized images already in the project. No new assets: the
remaining photographs in public_html are 876K and 3.5M, too heavy to ship.

Verified in a browser: three rows, the first open at load, opening the second
closes the first, its panel becomes visible, clicking again closes it, the
badge links to /contact, both images render, and at 390px there is no
horizontal overflow. No page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
…te (#11)

Search Console showed position 3.2 with clicks down 47%, and the indexed
listing advertised bikes, wedding rentals and tourist vehicles that the
rebuilt site did not mention at all.

Adds pages for bikes, wedding cars, tourist vehicles, monthly rental and NRI
visitors, each a separate route because each is a separate search. Corrects
the brand to NiteSha Cars & Bikes, which is what the business is called and
what Google already has indexed. Titles lead with Nagercoil rather than
Kanyakumari, and the home h1 now carries the primary keyword instead of a
slogan.

Structured data declares the service area as Kanyakumari district, and an
areas-served section names the twelve main towns as visible text. No street
address or coordinates are invented.

Motion throughout, built on CSS keyframes and one IntersectionObserver rather
than a motion library: 0.43 kB gzipped against Framer Motion's ~34 kB, which
matters on a site competing on local search. Only opacity and transform are
animated, and prefers-reduced-motion is honoured in both directions.

The home page gains three highlight bands for the high-value services and a
stacked-image accordion section.

No prices appear anywhere. The owner has not set terms for the long-stay
discount or airport pickup, and a figure on a live page is a promise a
customer can hold them to.
Gold led the brand but appeared only as a highlight on navy. It now carries
the hero and every page header, with warm cream behind the body in place of
the near-grey #FAF8F4, so the site reads gold rather than gold-trimmed.

The relationship had to invert rather than simply swap. #F5A500 as text on
white measures 2.05:1, well under the 4.5:1 body-text minimum -- gold can only
be a surface. Navy on gold is 9.36:1, so gold goes behind and navy on top.

This also fixes a contrast bug already shipped. --color-gold-deep was #B36B00,
used for links on light backgrounds, and measured 4.18:1 on white: failing.
It is now #8A5A00, which is 5.61:1 on cream.

Every pairing was measured rather than judged by eye:

  hero h1, navy on gold            9.36
  hero paragraph, navy/75 on gold  5.84
  page header h1 on gold           9.36
  body text on cream               7.13
  links on cream                  19.15

The alpha ones needed computing by hand. Reading them out of the browser gave
7424264.93 for the hero paragraph, because getComputedStyle returns rgba and
the script was not compositing it against what sat behind.

The highlights band stays navy on purpose. An entirely gold page is tiring to
read, and one dark section gives the gold somewhere to land, so it reads as
premium rather than relentless.

The hero photograph drops to 15% opacity with luminosity blending, since a
full-colour image under a saturated gold turns muddy.

Verified in a browser: no bg-sand remains anywhere, every measured pairing
passes AA for body text, and no page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The previous commit made gold the surface and navy the type. The logo
does the reverse — a dark navy field with a metallic gold monogram and a
gold rule beneath. The logo is the authority, so the site follows it.

Header, hero and page headers go back to navy with gold type. Gold
becomes metallic rather than flat: the #F0D060 -> #D4AF37 -> #C8873A
range from the logo, used as a gradient in .gold-rule and on the
monogram, with .eyebrow-gold for the "PREMIUM RENTALS" lockup.

Adds Logo.tsx, which draws the NS monogram rather than loading an image
because the artwork file hasn't been supplied yet.

Keeps the --color-gold-deep fix from the previous commit (#B36B00 was
4.18:1 on white, failing for link text; #8A5A00 passes) — that bug is
independent of which way round the surfaces go.

Measured on navy: #F0D060 12.67, #D4AF37 9.11, #C9A227 7.92, #C8873A
6.37 — all pass. Hero H1 19.15, nav and eyebrow 13.06, hero paragraph
at white/70 9.53, body on cream 7.13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Sits between "how it works" and the areas we cover: you have just read
how to book, so this is the payoff -- the road itself -- before the page
goes back to detail. It also breaks up four light sections in a row.

The picture is a CSS background rather than an <img> so a missing file
degrades to the navy beneath instead of leaving a broken-image icon on a
live page. The artwork is not in the repo yet; the band reads as a plain
navy panel with the gold rule until it is.

The scrim is gated at 1024px. Below that the text column still spans most
of the frame, so a sideways fade would put the type over its pale end --
at 640px that measured 4.79:1 against a deliberately blown-out stand-in.
With the flat wash instead, the worst case across 390-1920px is 7.17:1.

Contrast measured from the rendered pixels with the type hidden, so the
figures fold in the photograph, both scrim layers and the compositing
rather than trusting getComputedStyle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Rebuild the theme around the logo: navy surface, metallic gold on top
useSeo looked the route up with seo.routes.find(r => r.path === pathname).
Static hosts serve directory URLs with a trailing slash -- GitHub Pages
redirects /cars to /cars/ -- while seo.json and the sitemap store the bare
form, so the match failed for anyone arriving from search or refreshing the
page, and the lookup fell through to the not-found branch.

React Router matches trailing slashes, so the correct page still rendered.
That is what hid this: the screen was right while the head was wrong.

The head got, on every route but /:

  title       Page not found - NiteSha Cars & Bikes
  description empty
  canonical   https://niteshacars.in/cars/   (sitemap says /cars)

Google renders JavaScript, so that is what it saw -- on a site whose whole
purpose is local search. The prerendered HTML was always correct, which is
why link previews looked fine.

Normalises the trailing slash before the lookup and uses the normalised path
for canonical and og:url too, so they agree with the sitemap. Verified across
all 11 routes; 404.html still reports not found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Passwords are bcrypt digests, so a forgotten one cannot be recovered, only
replaced -- and nothing in the panel replaced one. install.php refuses to run
once an account exists (correctly: otherwise the file would hand the panel to
whoever found it), and tools/ only created users. Someone locked out with no
SSH had no way back in.

Runs two ways. Over SSH the token is not asked for, because reaching a shell
already proves you hold the server. In a browser, for hosting without SSH, it
refuses unless every one of these holds:

  - RESET_TOKEN has been changed from the placeholder and is >= 24 characters,
    so the file is inert as shipped
  - the token arrives by POST, keeping it out of access logs and history
  - the connection is HTTPS, or token and password cross the wire in clear
  - the file was uploaded within the last hour, so forgetting to delete it
    closes the hole by itself rather than leaving one open
  - wrong tokens are counted and answered with 429 after five, so the token
    cannot be found by trying

It compares the token with hash_equals, and deletes itself once a password is
set -- saying so loudly in red if that fails.

Resetting also clears failed_logins and locked_until. Without that, someone
who locked themselves out by guessing would set a new password and still be
refused, with nothing on screen saying why. is_active is deliberately left
alone -- a disabled account was disabled on purpose -- but the caller is told,
so a later refusal is not a mystery.

Verified against SQLite through the real CLI path: the hash changes, the new
password verifies, the old one stops working, the lockout clears and an audit
row is written. Mismatched, too-short and unknown-email cases change nothing.
Browser guards checked under a live server, including the throttle tripping
to 429 on the sixth wrong token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
…et tool (#13)

Fix every route reporting "Page not found" to crawlers, and add an admin password reset tool
The public site deploys to GitHub Pages on every merge. The PHP panel had no
route to its server at all, so api/public-vehicles.php has been sitting in git
while the live fleet pages have nothing to read -- the endpoint was written,
but never reached Hostinger.

Credentials are repository secrets. FTP_REMOTE_DIR is one too, rather than a
path hard-coded here: pointing this at the wrong folder would write admin files
over the main site, so the path is set by someone who can see the server. A
guard step checks all four are present and that the path ends in a slash,
because otherwise the FTP action fails deep inside itself with a connection
error that reads like the server is down.

Mirrors what git tracks -- files removed from git are removed from the server --
but dangerous-clean-slate stays off and config.php is excluded. Wiping the
folder would take config.php with it, and the panel stops with "Not set up yet"
when it is missing. That file is untracked precisely because it holds the live
database password, so a deploy must not be what destroys it. nitesha-storage
sits outside admin/, so scoping the deploy to admin/ leaves uploads alone.

A manual run defaults to a dry run, so the first use lists what it would change
before anything is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The push filter listed the workflow file among its paths, so merging it would
have run a real deploy immediately. The dry run is only reachable through
workflow_dispatch, so that first run would have written to the server before
anyone had seen what it intended to do -- the opposite of the point.

Triggering on the panel's own files only. The workflow is now started by hand
the first time, which is where the dry run lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Deploy the admin panel to Hostinger over FTPS
Two dry runs failed with "getaddrinfo ENOTFOUND", which reads like the server
is missing rather than like the value has a prefix on it. hPanel displays the
host as ftp://1.2.3.4, and pasting that whole string makes the client look up
a hostname of "ftp://1.2.3.4". The same error hides a trailing space, an
appended :21, or the surrounding label text coming along with a copy.

Rejects those up front with a message naming the actual problem, and reports
the value's length so a stray character shows up without the value being
printed. Nothing here prints the secret or anything that reconstructs it --
the repository is public, so the logs are public.

The lookup applies to hostnames only. Checking an IP with getent is a reverse
lookup, which fails whenever the address has no PTR record: testing caught
this rejecting the correct answer, since 8.8.8.8 and 1.1.1.1 have PTR records
and the Hostinger address does not.

Verified against a bare IP, a hostname, an ftp:// prefix, a trailing space, an
appended port, and the hPanel label pasted whole. Only the bare IP passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Check the shape of FTP_SERVER before handing it to the FTP client
The FTPS deploy puts everything tracked in git into the subdomain's web root,
so sql/001_schema.sql became fetchable -- the whole shape of the database,
every table and relationship, handed to anyone who asked for the URL. The
same went for README.md, SECURITY.md and the tools/ scripts.

install.php reads the schema from disk with glob(__DIR__ . '/sql/*.sql'), not
over HTTP, so denying it to the web does not affect installation.

tools/ is denied by default, with one exception. create-user.php and
clear-enquiry-throttle.php already refuse to run outside the command line, but
test-auth.php and test-money.php do not -- they expect $argv and would run for
anyone who loaded them. reset-password.php is explicitly allowed: its browser
mode is the way back in when the plan has no SSH, which is exactly the case
where a locked-out owner cannot reach the CLI tools. Blocking it would remove
the only route it exists to provide, and it already ships inert, demands
HTTPS, expires an hour after upload and stops answering after five wrong
tokens.

config.php is denied too. PHP executes it rather than printing it, so this
changes nothing while PHP is healthy; it matters on the day PHP is disabled
mid-upgrade and .php files are served as text, which is precisely when that
file should be least reachable.

Each rule is given in both Apache 2.4 and 2.2 form, guarded by IfModule.
Checked every file in the panel against the patterns: index.php, dashboard.php,
logout.php, install.php and the css and js are all still served.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
niteeshkanna-sh and others added 30 commits September 24, 2026 11:18
The owner changed the tab icon and still had the purple bolt this project
shipped with, months after it stopped being referenced anywhere. Nothing was
wrong with the file: /favicon.svg is the same address it has always been, and
a browser caches a favicon by its address and holds it far harder than it
holds a page. Chrome reads a new one when its own store forgets, which can
be months, and an ordinary reload does not hurry it.

So the prerender stamps both icons with a hash of the file: the address
changes when the picture does, nothing cached can match it, and nobody has
to know to clear anything. Every route and the SPA fallback are written from
the same template, so all 26 carry it.

Checked on the served site: the stamped addresses answer 200 and decode, the
tab icon 63% gold and the home-screen one 83%, and the page walk and banner
suite are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Address the icons by a hash of themselves
"More keywords" is usually taken to mean a keywords meta tag. Google stopped
reading that one in 2009 and says so plainly, so there is not one here and
adding it would have been a line of markup that does nothing. What does get
found is the site answering, in the words somebody used, a question they
asked -- so that is what this adds.

Ten questions and answers, on the home page and the tariff page: the
deposit, the documents, the kilometre allowance and what going over it
costs, delivery to the station and the airport, crossing into Kerala,
two-wheelers, monthly rates, a car with a driver, how early a wedding car
goes, and how to book. They are the calls the phone takes every day. As
prose they carry the phrasings the pages did not have -- without a driver,
scooty, car hire, international driving permit, railway station, long term
-- without a word of it being stuffed anywhere.

They are editable in the panel under Website content, because they are
business facts and the ones about deposits and Kerala have to be the
owner's answers rather than mine. They are written as <details>, so they
open with no JavaScript and every answer is in the HTML whether it is open
or shut, and the build turns the same content into FAQPage structured data
on exactly the two routes that display it -- Google asks that the data match
the page, and one copy of the text is how that stays true.

The business block on every page now carries an offer catalogue naming the
five things this business hires out, in the words they are searched for
rather than the site's own headings. Seven titles and descriptions gained a
phrase each where one read naturally.

And the build now refuses a title over 62 characters or a description over
160, which is where a search result starts cutting. The town hub's
description was 174 and had been losing its last two towns since it was
written.

Checked on the built site: fifteen pages, each with one h1, one title and
description inside the limits, the business block on every one, and the FAQ
data matching the ten questions actually shown -- on the two pages that show
them and nowhere else. The page walk and the banner suite are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Answer the questions people type, and name the services in the markup
Each of the three was an eyebrow, a paragraph, a bordered panel with its own
heading and four bullets, and a full-width gold button. Side by side that is
about a hundred and forty words and twelve bullets in a row of boxes, at the
point in the page where somebody is still deciding whether to keep
scrolling. Nobody reads that, and the owner did not like looking at it.

One sentence each now, and the boxes are gone: three columns divided by
hairlines, which read as one band rather than three objects -- which is what
they are. Sixty-nine words where there were two hundred and fifty, and the
band is 378 pixels tall instead of a thousand.

What is left is enough to earn the click, and the page it links to is where
the detail belongs. The panel's form lost the fields with it: a title, a
sentence, the link text and where it goes.

The emoji went too. They were typed into the panel as 🗓 ✈ 💍, and a browser
draws the first two as flat black glyphs and the third in full colour, so
the row read as two missing icons and a ring. Each service has a drawn mark
now, the same weight as the others, in gold inside a thin gold ring.

Checked at 1440 and 390: three columns on a laptop, three stacked entries
divided by the same hairline on a phone, no overflow either way. The panel
form shows the four fields and nothing else. The page walk flags nothing and
the banner, image-address, accessibility-name and per-page SEO suites are
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three lines on the dark band, not three walls
The dark band goes. The page's own cream carries it now, and the three stop
being three of the same box: the first is the one worth the most, so it is
the big one -- gold, tall, with a solid navy button -- and the other two are
quiet white rows beside it with the mark on the left, where a list's marker
would be.

Three of an identical card says these are three equal choices. They are not.
One is a month of hire, one is a booking made from another country, one is a
wedding, and the layout can say so before a word is read.

Deliberately not the card the fleet grid above it uses -- picture on top,
title, line, link. Two sections of the same card on one page read as one
long list that lost its heading, which is also why this one has a heading
now: without the dark band behind it, a section with no title is a section
that has come loose.

Measured on the real pixels rather than the declared colours, because the
big card is a gradient: the heading is 16.9:1 on it, the sentence 5.6:1, the
button 19.2:1. At 390px the three stack in the same order with nothing
overflowing. The page walk flags nothing and the banner, image-address,
accessibility-name and per-page SEO suites are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The words were written into the component, so the one line on the page the
owner most wanted to change was the one he could not. It comes from the
panel now, like the heading under it, and it says Our Services.

Bigger with it: 16px, 17 on a wide screen, where it was 14. Wide-tracked
gold caps are read as a label rather than as a sentence, and at 14 that
label was smaller than the body text beneath it.

All four of them, not just this one. Services, the three cards, Booking
start to finish and Where we deliver are the same thing on the same page,
and one of them being bigger than the others reads as a mistake rather than
as emphasis. They were also the same five utility classes written out four
times; they are one class now, so the next time the size is wrong it is
wrong in one place.

5.5:1 against the cream behind it, which is above what 17px bold needs. The
page walk flags nothing and the banner, image-address and
accessibility-name suites are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"Our Services", and the line above a heading reads like one
The three steps were the same card as the fleet grid further up the page --
a picture with a bordered box under it -- so two sections read as one long
list of cards, and nothing in the design said these three happen in an
order. The order is the whole point of the section.

So the boxes go and the order is drawn: a numbered node per step with a gold
line running from it towards the next, the picture and the words below it.
The line is not decoration -- it moves left to right, in the order the steps
happen, and it draws itself as each step arrives.

The node lands first and the line sets off after it, both hung off the
reveal the step already had, so the stagger that was there for the fade
carries the rail too. Under the pointer a step's picture grows a little
inside its frame, which already clips, so nothing moves on the page.

Everything is off for prefers-reduced-motion, and the line needs saying
separately: it is drawn rather than faded, so an untouched scaleX(0) with
the animation disabled is an invisible line rather than a still one.

Measured rather than assumed: the line goes 0 to 349 pixels over about half
a second when motion is allowed, and is 349 from the first frame and never
moves when it is not. Still an <ol>, so the order is there for a screen
reader as well as for the eye. At 390px the three stack with the line
hidden, where a line running right would run nowhere. The page walk flags
nothing and the banner, image-address, accessibility-name and per-page SEO
suites are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The row under the banner headline was three sentences of small grey type --
"Unlimited-choice pickup across the city" and two more. Three sentences side
by side under a headline are read as a paragraph, which is to say they are not
read at all, and the first of them was not really English.

So: three badges. A tick in gold, two or three words beside it, on a tinted
glass pill that lifts a little under a pointer. Nothing in a badge to skip.
The words stay the panel's -- "Delivered to your doorstep", "Zero hidden
charges", "Day, week or month" are the new defaults, not new hardcoding.

The footer's logo goes. It was the same artwork as the header's, larger, at
the other end of the same screen, with nothing between the two that needed
reminding whose site this is. The business name set in type signs the page off
just as well, stays crisp at any size, and is what a search result quotes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The FAQ was a white list on cream at the foot of the page, which is where
small print goes. It is not small print -- it is the ten things people ring up
to ask, answered in the words they would type into Google.

So: a band. The banner photograph almost entirely under navy with the same
gold glow the rest of the site uses, the heading and its line centred in
white, and the questions as cream cards over it, each with a round gold badge
that turns from a + into a x when its answer is open.

Only one answer at a time. <details name="faq"> is enough on its own in
Chrome 120, Safari 17.2 and Firefox 130 and later -- the browser shuts the
open one with no script at all -- and the toggle handler does the same by hand
in anything older, finding nothing to close where the browser already did it.

Two stacks rather than a two-column grid. In a grid the row grows to the
taller of the pair, so opening an answer leaves the card beside it hanging
over a hole; each stack closes up on its own instead. Split in half rather
than alternating, because on a phone the two become one and its order is the
order of the markup -- deposit, documents, kilometres, which is the order they
are asked in.

Every answer is still in the HTML whether it is open or shut, so the FAQPage
structured data the build writes from the same content still matches the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every route on this site is one Suspense boundary, and a prerender emits the
shell first and the boundary after it -- the whole page inside
<div hidden id="S:0">, put on screen by a script at the end of the body. So
the words were in the file and nothing that does not run JavaScript could see
them. With scripting off the site was a navy band under the header. The point
of prerendering is the readers that do not run scripts; this was the one thing
it was not doing.

Two passes now. The first is thrown away: it is what resolves the route's
React.lazy chunk, which is the only thing these pages ever wait for.
renderToString then writes the same tree with the boundary and its contents
where they belong -- it cannot wait for anything, which is exactly why the
pass before it exists. If a route ever suspends on something else,
renderToString throws, the build says which route and falls back to the
streamed HTML it shipped before, and prerender-seo says what that costs.

The second half of the same problem was the scroll reveals: two thirds of
each page is [data-reveal], which starts at opacity 0 and is released by an
observer. Hiding it was unconditional, so with no script the page was
delivered and then hidden. One line in the <head> sets data-js before
anything paints, and the hiding rules are keyed on it -- the page is visible
by default and hidden only where something can unhide it. The rail on "How it
works" is the same: undrawn only where there is an observer to draw it.

Checked with scripting disabled: all 47 revealed sections visible, the pages
are full height, and the FAQ opens one answer at a time on <details> alone.
With scripting on: hydration is silent on four routes, client-side navigation
still works, the reveals still hide and release on the way down, the rail
still draws (0 - 54 - 190 - 282 - 338), and reduced motion still shows
everything at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Hero badges, a footer in type, an FAQ band, and pages that don't wait for a script
Cropped at the line under "CARS & BIKES", six pixels below it -- the same
margin the artwork leaves at the top -- so the mark, the name and the trade
line are untouched and only the tagline is gone. 1209x705 where it was
1209x771.

Under a new name. Images are served with a week's cache, so keeping the old
one would have gone on showing the old lockup, tagline and all, to everyone
who had already been to the site. A new name is a new URL, and nothing cached
can answer for it. The old file is deleted rather than left beside it: the
only thing that referenced it was LOGO_FALLBACK.

The header, the footer and the brand panels all read that one constant, so
all three follow. An upload in the panel still overrides it, which is what
the owner is looking at if the old one is still there after this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The menu opened onto six pages, so "what do you actually hire?" was six
clicks and six back buttons -- and /services, the page that should have
answered it, was the home page's card grid again: six pictures with a
sentence each and "See details".

It is one page now. Each service gets a block of its own: the drawing on one
side, the name with its numeral, what it is, four ticked points of what comes
with it, and the two ways to ask. The picture changes sides as you go and the
background alternates cream and white, so six blocks read as six places
rather than one long scroll. A row of buttons at the top jumps to whichever
one somebody came for, by real anchors, so the link can be shared.

The ticks are the panel's, in a new "Tick points" box on each service, one
per line. A service saved before this field existed renders without them
rather than not rendering.

The six pages underneath are untouched and linked from every block -- they
are separate searches, and a page can only rank for what it is about -- but
the menu is one link now instead of a list, and it stays lit while you are on
any of them.

Checked: six blocks, six jump buttons that each land on their block,
twenty-four ticks, the picture alternating sides all six times, no dropdown
left in the header, none of the six in the menu, all six still serving 200,
and a silent console. The page walk, the accessibility names, the FAQ, the
reveals and the no-JavaScript pass are all unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Everything we hire on one page, and a logo without its tagline
Who we are, where to go, the company, how to reach us, where we are. The
ways to reach us get a column of their own with a mark against each -- gold
for the number to ring, green for the one to message, the envelope and the
pin under them -- because that is what somebody scrolling this far is looking
for, and a list of plain links makes them read all five to find it.

Services is one link now. The column named five of them and then offered
"View all services" underneath, which is six ways of saying the same thing;
/services carries every service in full since the last change, so one line is
the whole answer.

WhatsApp leaves the follow row. It is a way to message us, not a page to
follow, and two WhatsApp links a hand's width apart read as a mistake -- the
contact row carries it now, with the number beside it, and the panel's
WhatsApp box is what it links to. The row of icons is round rather than
square with it, and its heading belongs to the column that places it: with
the panel's social boxes empty, that heading was sitting over nothing at all.

The map keeps its place under "Find us", and the line at the bottom carries
the copyright on one side and the two things somebody down here still might
want on the other.

Checked: four column headings, five blocks on one row at 1440 and stacked on
a phone with no sideways scroll, one link to /services and none to the six
under it, one number to ring and one to message, an address, an email, the
map, both ends of the bottom row, every footer link resolving 200, and a
silent console. The page walk, the accessibility names, the services page,
the FAQ and the no-JavaScript pass are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A footer in five columns, and services as one line in it
Three cards in a row, each with a photograph and a numbered node on a drawn
rail. The numeral -- the only part of a step that carries the order -- was the
smallest thing in the card, and three small photographs were three things to
look at and nothing to read.

Now the numeral is the largest thing in the step, the steps stand in two
columns, and one picture stands between them: three corners round and one
square, which is the one shape on this page that is allowed not to be a
rectangle. Each step is a numeral, a title, a sentence and a hairline that
fades out, which separates them without drawing a box around each.

Four steps rather than three, because the layout reads two and two -- and
because the fourth was missing from a section called "booking, start to
finish": the car comes back, we check it, the deposit follows. It is a
content default like the other three, editable and removable in the panel.

The picture keeps the middle step's slot rather than taking a new name. The
site only ever sees a slot the panel's list names, so a new key would have
quietly hidden a photograph already uploaded there. Its crop is 4:5 now; the
other two step slots are gone from the panel, since nothing on the site reads
them any more. The files stay on the server.

Still an <ol>, and still 01 02 03 04 in the markup: the two columns are a
layout, not a change to what comes after what. The list is laid out through
its parent's grid, so role="list" is stated -- Safari drops list semantics
from a list that generates no box of its own.

Checked at 1440: four steps, two per column, 01 and 03 level, the picture
between them and taller than it is wide, one <ol> announced as a list, the
markup in order. On a 390px phone: one column in order with the picture above
it and no sideways scroll. The page walk, accessibility names, reveals,
no-JavaScript, footer, services and FAQ suites all pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The steps stand either side of one picture
Folders rather than boxes, stepped down and up across the row, in the four
colours the site already owns -- navy, cream, gold, bronze -- with the stamp
turning slowly in the gap between the middle two. It carries the line the
logo used to have under the name, which is where it came from.

What they say is what the owner asked for: trusted 100%, cleaned before
pickup, 1000+ customers, 20+ vehicles. Those last two are claims that change,
so they are content rather than markup -- a fleet of twenty is twenty until
it is thirty, and a number written into a component is a number nobody can
correct without a deploy.

Which meant the panel needed a second page. The schema was one page deep in
shape but only ever held "home", and content.php already read ?page= and
saved under it, so what was missing was a way to get there: a row of two
chips above the sections, links rather than script, so a page is somewhere
you can be sent and somewhere the back button returns to.

The stagger is margin rather than a transform. These cards are revealed on
scroll and the reveal animates transform to nothing at its last frame, so a
translate here was undone the moment each card arrived -- which is why the
first attempt was a flat row.

Checked against the real panel: the switcher lists both pages, the about page
prefills from the defaults, an edit saves and comes back, the home page is
untouched by it, the public endpoint carries the edit under about, and reset
puts the shipped wording back. On the site: the cards step, the colours are
the four, and the page walk, accessibility names, how-it-works, footer,
services, FAQ, reveals and no-JavaScript suites are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Twenty-eight pixels of hairline on a coloured card is a smudge, and four of
them were four smudges. The mark is forty pixels now, on a disc of its own:
gold on the navy card, bronze on the cream one, navy on the gold one and cream
on the bronze, so the row carries four colours rather than one gold repeated
four times.

Each one draws itself as its card arrives -- the outline first, the detail
after it -- and then keeps a slow float, offset a beat per card so the row
breathes rather than pulsing in time. Under a pointer the disc grows and tilts
a little.

pathLength="1" on every shape, so one dash rule draws all of them: measured in
user units, the shield outline would crawl while the tick was over before
anybody saw it.

The undrawn state is keyed on data-js, like the reveals: with no script there
is nothing to add .is-visible, and an icon left undrawn is an icon nobody
sees. Reduced motion gets them drawn from the first frame, with no float and
no hover.

Checked: the marks start at a dash offset of 0.999 and finish at 0, four
badges at 72px with four different colours and four different mark colours,
the mark itself 40px, and -- the two that matter -- drawn from the start under
reduced motion and drawn with no script at all. The page walk, accessibility
names, reveals and no-JavaScript suites are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every icon on both was a hairline in one colour, drawn a piece at a time in
whatever component needed one -- a phone, a pin and an envelope in identical
grey say nothing about which is which before you read the line beside them.

One recipe now, in two places because the two share no build: a plate with a
diagonal gradient, a gloss across its top half, a shadow underneath and a rim
inside the edge -- the four things that make a flat shape read as an object --
with the mark in white on top. Six tones: gold, bronze, navy, green, plum,
slate, so a row of icons carries colour rather than one gold repeated.

On the site (my-app/src/components/Icon3d.tsx): the footer's four contact
rows, the social row, the WhatsApp bubble and the back-to-top button. In the
panel (admin/src/icons.php): all eight sidebar entries, each on its own
colour, and the five counters on the dashboard, where the plate sits top
right and out of the way of the number.

Not everywhere, deliberately. The chevron beside a menu, the tick inside a
chip, the bin on a table row: a plate at sixteen pixels is a coloured square
with something indistinct on it, and those are clearer as hairlines. The four
marks on the about page keep their own discs, which already draw themselves.

Ids are suffixed per instance in both. Six plates on one page all defining
"plate" would every one of them paint with whichever gradient the browser
read last -- the failure is silent and looks like a colour choice.

Checked: 14 checks over both -- plates in the footer and the buttons with two
gradients and a shadow each, none under 38px, no two definitions sharing an
id, eight sidebar plates at 22px in four or more colours, five counters at
34px with none sitting on its number, and nothing thrown either side. The
page walk, accessibility names, footer, benefits, services, how-it-works,
FAQ and no-JavaScript suites are unchanged, as are the panel's nav, UI and
records suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The about page's four cards, and one colour icon recipe across site and panel
Two cards and then a form centred in its own band meant that on a desktop the
page was a column down the middle with a screen of nothing either side, and
the phone number was a scroll away from the form by the time anybody had read
it. Side by side, both are on screen at once.

On the left: phone, WhatsApp, email and the address, each on the plate its
kind of contact uses everywhere else, then the opening hours and the map --
which belongs on the page somebody opens to find us rather than only at the
foot of every other one. All of it the panel's: the address, the hours and
the map pin come from the footer section, so there is still one place to
change them.

On the right: the form. Enquiry is a card the page places now rather than a
section that centres itself, which is the whole of the change to it -- same
fields, same handler. The id stays on the card, because /contact#enquire is
linked from half the site and has to land on the form rather than on the
column holding it.

Under a thousand pixels they stack, details first: most people opening this
page on a phone want the number, not the form.

Checked: four ways on the left, the form to the right of them and level with
them, 780px of room for it, the anchor landing on the form, every field still
posted, one map, stacked in that order on a 390px phone with no sideways
scroll, and a silent console. The page walk, accessibility names, the icon
plates and the no-JavaScript pass are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Contact: the ways to reach us on the left, the form on the right

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants